Как показать миллион зданий на карте — и не сломать браузер

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

В 2ГИС мы аккумулируем огромное количество геоданных, с которыми взаимодействуют миллионы пользователей ежедневно. Анализируя их, мы можем получить ценную информацию и найти важные идеи для развития городов. Эти данные также полезны организациям.

Чтобы помочь бизнесу и муниципальным организациям, мы создали 2GIS PRO — инструмент для GPU‑аналитики, с возможностью визуализации огромного количества данных на карте в виде диаграмм и графиков.

Расскажем, как мы получаем такую картинку, как это всё работает под капотом, и посмотрим, на что способен ваш браузер, ведь ему предстоит отображать сотни тысяч объектов одновременно.

На старте у нас было всего 2 основных требования со стороны будущих пользователей:

  • «Хочу фильтровать все объекты по всем интересующим меня атрибутам и видеть агрегированную информацию».

  • «Хочу видеть все здания Москвы и области в виде гексагонов на карте и раскрашивать их в зависимости от этажности».

А теперь поговорим об этом подробнее.

Как фильтровать и агрегировать всё подряд

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

Мы выбрали для этих целей elasticsearch, у него из коробки было практически всё, что требовалось:

  • Возможность хранения миллионов документов

  • Быстрая индексация по произвольным атрибутам, включая геопоиск

  • Хорошая агрегация данных

Оставалось только сверстать json-описание агрегатов — описать в общем виде представление, в котором хочется показывать данные на клиенте по выбранному типу объектов.

В итоге мы получаем вот такую картинку

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

Кусочек конфигурации, на основе которого получается результат выше:

[
    {
      "caption": "Total count of buildings",
      "agg_type": "value_count",
      "group_id": "building_counts",
      "placement": "header"
    },
    {
      "filter": [
        {
          "tag": "purpose_group",
          "type": "number",
          "value": "1000001"
        }
      ],
      "caption": "Administrative and Commercial",
      "agg_type": "value_count",
      "group_id": "building_counts",
      "placement": "chart"
    }
]

Как показать миллион гексагонов

Разберем второе требование к продукту — заказчики хотят видеть все здания Москвы и области в виде гексагонов и раскрашивать их в зависимости от этажности. Погодите, но ведь речь идёт о миллионах объектов!

Вообще, для отображения карты у нас есть классный mapGL-движок, который умеет показывать все наши геообъекты на карте и делает это довольно быстро. Но у него есть ряд особенностей: данные довольно статичны, они нарезаны на тайлы и на клиент загружается относительно небольшой сет упакованных в тайлы данных, попадающих во вьюпорт. Хочешь показать редко-меняющиеся данные и есть время на их предварительную подготовку и нарезку — используй этот движок.

Но если нужна динамика и гибкая настройка визуализаций в виде гексагонов, гридов, хитмапов, то потребуется другой механизм. Для решения этой задачи мы взяли библиотеку deckgl — очень крутой опенсорсный инструмент, который из коробки давал нам практически всё, что требуется.

Осталась одна проблема — объектов всё равно миллионы. Чтобы красиво нарисовать те же гексагоны, требуется загрузить все данные, рассчитать минимумы/максимумы, высоты, палитру и все остальные параметры, зависящие от атрибутов данных. А потом — отрисовать всё это богатство поверх нашей базовой карты. И желательно делать это не на топовом железе, с каким то вменяемым уровнем потребления памяти, и чтоб не тормозило и можно было пользоваться и делать необходимый анализ.

Подготовка данных

Погодите, но не зря ведь придумали пэйджинг, агрегации, симплификации, тайлирование и множество других умных слов! Всё это нужно, чтобы уменьшить количество объектов до какого-то вменяемого количества, обычно это несколько десятков, максимум сотен. Миллион объектов отдавать на фронт нельзя, работать такое просто не будет. 

Или будет?

Да если и будет, то загрузки и обработки данных придётся ждать минуты (или часы, если не повезет). Всё будет крепко тормозить — пользователи будут недовольны. Одним словом, всё нереально.

Или реально?

Первые эксперименты показали, что это реально. Но есть нюансы

  • Такое количество данных нужно выгрести из эластика. А в нашем случае нужно еще и фильтровать данные по заданным критериям. Иными словами выборка отличается от запроса к запросу — и с ходу её не кешировать.

  • Нельзя просто так отдать json в 2 миллиона объектов. Это очень долго, от нескольких десятков секунд до нескольких минут. Даже в режиме потокового чтения-сжатия-отправки данных. 

Простое и очевидное решение: готовим данные к отправке в фоне, а на клиент временно отправляем кластеризованный результат в несколько сотен и даже тысяч объектов. Тут нет никакого рокет-сайнс: хочешь много данных, придётся подождать. 

Сама подготовка данных — это просто задача, которая выбирает данные из хранилища, складывает их в gzip файл и отправляет этот файл в файловое хранилище (в нашем случае — в s3). А на клиент при очередном запросе отправляет уже готовый файл в несколько килобайт или мегабайт.

Это происходит относительно быстро, данные уже готовы, не нужно доставать их из базы, не нужно сжимать, просто стримим файл в выходной поток и всё. 

А дальше — магия фронтенда.

Подготовка данных для работы в браузере

Для примера того с какими объемами нам нужно работать, возьмем набор данных со всеми зданиями Москвы. Это, на минуточку, 170 тысяч объектов, которые выглядят следующим образом:

{
    "id": "70030076129543595",
    "point": {
        "lon": 37.874927,
        "lat": 55.739908
    },
    "values": {
        "area": 9,
        "building_id": "70030076129543595",
        "floors_count": 1
    }
}

Количество ключей в values может быть разнообразным, от 2 до N полей. Это количество ключей от того, какая информации по каждому зданию у нас есть: этажность, год постройки, кол-во подъездов, тип здания, кол-во проживающих / работающий людей и др.

Этот слой удобно использовать как подложку для обозначения области аналитики, так как здания естественным образом образуют нужный контур. А поверх можно наложить еще несколько слоев с анализируемыми данными, например со спросом, который даст еще несколько десятков тысяч объектов. Итого — иметь несколько сот тысяч объектов в одном проекте не аномалия, а вполне себе стандартный пользовательский сценарий.

Как работать с такими объемами данных и не блокировать интерфейс

Большинство современных браузеров могут выносить часть работы в отдельный Background Thread через Web Workers API. Изучив все возможности, мы поняли, что можем абсолютно безболезненно вынести всю работу с получением и подготовкой данных в этот слой. 

Для удобной работы с WebWorker используем библиотеку СomLink от команды разработчиков Google Chrome.

Интерфейс WebWorker’а — вот такой:

type PromisifyFn<T extends (...args: any[]) => any> = (
...args: Parameters<T>
) => Promise<ReturnType<T>>;

const worker = {
   requestItemValues: async (assetId: string, services: Services) {
       // В данном методе мы:
       // Запрашиваем данные через Axios (библиотека http запросов)
       // Складываем их в хранилище данных (кэширование на клиенте)
   }
   getDeckData: (layerId: string) {
       // В данной функции мы подготавливаем данные для работы в deck.gl
   }
   // … другие вспомогательные методы
}

type ProWebWorker = typeof worker;

type ProWorker = {
   requestItemValues: PromisifyFn<ProWebWorker[‘requestItemValues’]>,
   getDeckData: PromisifyFn<ProWebWorker['getDeckData']>
}

Как видно, у нас есть 2 основных метода, которые реализуют сначала запрос данных, а после - получение их в нужном формате. 

Логика получения с сервера данных не сильно отличается от того, как бы мы это делали в обычном приложении, поэтому перейдем к более интересной части. Поговорим про подготовку данных.

Бинарные данные

На первом этапе исследования работы с фоновой обработкой данных, мы только запрашивали данные и разбирали JSON через JSON.parse, остальные же операции делали в основном потоке.

Вскоре у нас появился очень большой набор данных о спросе — и приложение опять стало блокировать основной поток. Ребята из команды WebGL карты рассказали, что решали похожую проблему через переход к бинарным данным.

Оказалось, у deck.gl дружественный интерфейс для бинарных данных. Это позволяет нам максимально эффективно использовать WebWorker. Дело в том, что передача данных в виде типизированных массивов из фонового потока работает гораздо более эффективно чем передача в любом другом формате, при этом нам не требуется дополнительно трансформировать данные в основном потоке. 

Также при передаче бинарных данных важно использовать Transferable Objects чтобы не тратить лишней памяти. У библиотеки Comlink есть специальный метод transfer.

Давайте посмотрим как выглядит формат бинарных данных на примере визуализации Grid

export function getGridData(data: GridHexLayerData) {
  return {
    length: data.positions.length / 2,
    attributes: {
      // В значениях у нас будет массив Float32Array со значениями координат
      // [37.575541, 55.724986, 36.575541, 54.724986]
      // Size же говорит нам сколько нужно взять элементов массива 
      // чтобы получить координаты одного элемента
      getPosition: { value: data.positions, size: 2 },
      // В цветах у нас Uint8ClampedArray 
      // [255, 255, 255, 255, 255, 255, 255, 255]
      // Тут мы уже берем 4 элемента чтобы превратить это в RGBA
      getFillColor: { value: data.colors, size: 4 },
    },
  };
}

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

Агрегация данных

Для части визуализаций (grid, hex, h3) требуется предварительная агрегация. В исходных данных у нас представлены полноценные наборы без каких либо трансформаций на сервере. Это нужно для того, чтоб не перезапрашивать данные с сервера при каждом изменении способа визуализации или её параметров.

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

Помимо этого мы храним значения различных атрибутов для быстрого доступа к ним из подсказок и карточек объекта. 

Производительность

В начале рассказа о Web Worker, я привел пример двух слоев, состоящих из примерно 200 000 объектов. Насколько быстро мы сможем выполнить получение, подготовку и отображение этих данных?

Как видно на демо, загрузка не превышает 3 секунд. При этом слой с тепловой картой (данные по спросу) появились в момент отображения карты. Если же взять все реальные проекты, которые мы изучали, максимальная длительность на среднем офисном ноутбуке составляла 12 секунд, при объёме данных в несколько миллионов точек.

Отсутствие лишней работы на сервере, быстрая клиентская фоновая загрузка и подготовка данных, бинарный формат и эффективный GPU движок в deck.gl позволяют отображать практически "безграничный" объём данных!

Что еще можно попробовать

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

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

Благодарности

С написанием стать по части фронтенда мне помогал Кайсаров Кирилл, за что ему огромное спасибо!

Источник: https://habr.com/ru/companies/2gis/articles/755620/


Интересные статьи

Интересные статьи

Мы добавили секционирование сетевого состояния в браузер и тем самым ещё сильнее улучшили его защитные свойства. Сетевое состояние - это набор разного рода кэшей (фавиконок, шрифтов, id сессий HTTPS),...
Что делать на работе, если не знаешь, чем бы еще заняться? Конечно же писать на Хабр!Благодаря нашим доблестным законодателям, дальнейшее развитие бизнеса самостоятельно ...
Привет, Хабр! Прошедший год был полон самых неожиданных событий и сюрпризов, как приятных, так и не очень, и одним из таких новшеств, вынужденно ставших в нашей жизни уже почти нор...
Целью данного проекта было вывести цветное изображение на чёрно-белый монитор путём наложения на экран распечатанного на ацетатной плёнке (на струйном принтере) фильтра Байера. Цветно...
Виртуальный геохронологический глобус, на котором можно увидеть, как выглядела поверхность нашей планеты в разные эры (Нео-протерозой, Палеозой, Мезозой, Кайнозой), начиная от временного пром...