Тайлы, открытые данные и маршрутные сети авиакомпаний

13.03.2026
Тайлы, открытые данные и маршрутные сети авиакомпаний

Давно хотел сделать что-то визуально интересное и технически нетривиальное — не очередной todo-лист и не клон какого-нибудь туториала, а что-то своё, с реальными данными и понятным смыслом. В итоге получился проект airlines.macrulez.ru — интерактивная карта маршрутных сетей авиакомпаний. Выбираешь авиакомпанию из списка, и видишь откуда и куда она летает: дуги маршрутов, точки аэропортов, подписи городов. Всё рисуется на canvas поверх тайловой карты мира.

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


Часть 1. Тайлы карты мира

Первое, что нужно для любого картографического проекта — сама карта. Казалось бы, очевидное решение — взять OpenStreetMap или любой другой публичный tile-сервер. Но это зависимость от внешнего сервиса: сегодня работает, завтра поменяли политику или просто лежат. Плюс OSM содержит избыточную детализацию: улицы, здания, маршруты общественного транспорта. Для карты маршрутных сетей авиакомпаний всё это не нужно — достаточно контуров стран и береговых линий.

Поэтому решил сгенерировать тайлы самостоятельно.

Источник данных — GeoJSON

Взял публичные данные о границах стран и континентов в формате GeoJSON. Это стандартный открытый формат: каждая страна описана набором полигонов с координатами в виде пар [longitude, latitude]. Данные бесплатные, несколько мегабайт — граница каждой страны с достаточной точностью для мирового масштаба.

Как работает тайловая сетка

Система тайлов устроена просто. Весь мир делится на квадратные тайлы по схеме z/x/y:

  • z — уровень зума (масштаб)
  • x — горизонтальная координата тайла в сетке
  • y — вертикальная координата

На каждом следующем уровне зума количество тайлов умножается на четыре: zoom 0 — один тайл на весь мир, zoom 1 — четыре, zoom 2 — шестнадцать, zoom 3 — шестьдесят четыре, и так далее. Формула: 4^z тайлов на уровне z.

Скрипт генерации работает так: для каждого тайла вычисляем географические границы (bbox), берём из GeoJSON только те полигоны, которые попадают в этот bbox, проецируем координаты в пиксельное пространство и рисуем на PNG-холсте нужного размера. Получаем набор файлов 0/0/0.png, 1/0/0.png, 1/0/1.png и так далее.

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

Хранение тайлов

Готовые тайлы загрузил в S3-совместимое объектное хранилище. Фронтенд забирает их по URL вида /{z}/{x}/{y}.png — это стандартный tile-протокол, который понимают все картографические движки. Никакого tile-сервера, никакого посредника — просто статика из S3.


Часть 2. Данные об авиакомпаниях и маршрутах

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

AirLabs API

Первый источник — AirLabs (airlabs.co). Это коммерческое API с авиационными данными. Есть бесплатный tier на 1000 запросов в месяц — для периодических обновлений хватает.

Оттуда брал:

  • Список авиакомпаний с IATA и ICAO-кодами, названиями, странами регистрации и флагами активности.
  • Маршруты тоже можно загружать через AirLabs, но лимит в 1000 запросов быстро кончается, если авиакомпаний много. Ryanair один летит в 237 аэропортов — это уже несколько запросов только на одну АК.

Поэтому для маршрутов нашёл другое решение.

GitHub: Jonty/airline-route-data

Репозиторий Jonty/airline-route-data на GitHub — бесплатный открытый источник маршрутных данных, который обновляется еженедельно. Данные в JSON, структура простая: для каждой авиакомпании список маршрутов в виде пар аэропортов.

Написал скрипт, который клонирует (или обновляет) этот репозиторий и парсит маршруты. Каждая пара dep_iata → arr_iata пишется в базу батчами по 500 записей — так и быстрее, и не нагружает базу тысячами одиночных INSERT.

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

Переводы через Wikidata SPARQL

Отдельная задача — названия на русском языке. AirLabs отдаёт только английские названия. Брать переводы из платных источников не хотелось.

Решение — Wikidata SPARQL. Wikidata — публичная база знаний от фонда Wikimedia, где хранятся структурированные данные в том числе для авиакомпаний и аэропортов. SPARQL — язык запросов к этой базе. Доступ бесплатный, без API-ключа.

Написал хелперы fetchAirlineTranslationsBatched и fetchAirportTranslationsBatched. Они формируют SPARQL-запрос, передают батч из 50 объектов (больше сервер начинает отклонять), получают переводы и сохраняют в отдельные таблицы. Потом при запросе к API переводы джойнятся к основным данным.


Часть 3. База данных и скрипты импорта

В качестве БД — PostgreSQL, запущенный в Docker-контейнере. Схема получилась довольно плоская — никаких хитрых связей, всё через IATA-коды:

  • airlines — авиакомпании: IATA, ICAO, название на английском, страна, флаг active
  • world_airport — аэропорты: IATA, ICAO, координаты (latitude_deg, longitude_deg), тип (large_airport, medium_airport и т.д.), страна, город
  • routes — маршруты: dep_iata, arr_iata, airline_iata, last_updated
  • airline_translations — переводы авиакомпаний: iata, lang, name
  • airport_translations — переводы аэропортов: iata_code, lang, name, city

Всё взаимодействие с базой идёт через databaseService — это собственная обёртка с методами select() и execute(). Поддерживает параметризованные запросы (чтобы не было SQL-инъекций), и встроенное кеширование с TTL — передаёшь запрос, параметры и время жизни кеша, дальше сервис сам решает, идти в базу или отдать закешированный результат.

Для статистических запросов, которые дёргаются при каждой загрузке страницы, TTL = 3600 секунд (1 час). Данные об авиакомпаниях и маршрутах меняются редко — смысла каждый раз бить в базу нет. Для редактирующих операций кеш сбрасывается вызовом clearCache().

Последовательность импорта

Скрипты импорта написаны на Node.js. Запускаются вручную или по расписанию. Порядок такой:

  1. Авиакомпании из AirLabs — загружаем список, пишем в airlines. Если запись с таким IATA уже есть — обновляем, иначе вставляем.
  2. Аэропорты — аналогично, с координатами и типами. Координаты критически важны: без них маршрут не нарисовать.
  3. Маршруты из GitHub — клонируем/обновляем репозиторий, парсим JSON, батч-инсерт по 500 записей. Старые маршруты АК предварительно чистим.
  4. Переводы из Wikidata — запускаем отдельно. Скрипт находит записи без перевода на нужный язык и батчами (по 50 штук) тянет из Wikidata.

Между запросами к AirLabs — пауза 200ms. Это стандартная защита от rate limit: лучше потерять несколько минут на паузах, чем получить 429 на середине импорта и потом разбираться что успело записаться, а что нет.


Часть 4. REST API

Backend — Node.js + Express, тоже в Docker. Три основных контроллера, каждый отвечает за свою предметную область.

routeController — маршруты

Самый нагруженный контроллер. Ключевые эндпоинты:

GET /api/airlines/routes/stats/airlines — возвращает список авиакомпаний, у которых в базе есть хотя бы один маршрут, с агрегированной статистикой: количество маршрутов, число аэропортов вылета и прилёта, количество уникальных пар. Сортировка по убыванию числа маршрутов. Ответ кешируется на час — эти данные меняются только при обновлении базы. На фронтенде именно этот эндпоинт используется для заполнения селектора авиакомпаний: показываем только тех, у кого реально есть данные.

GET /api/airlines/routes/airline/:iata/destinations — полная маршрутная сеть конкретной АК. Возвращает все пары dep_iata → arr_iata с базовой информацией об аэропортах. Это тяжёлый запрос — у крупных АК 5000+ маршрутов. Поэтому тоже кешируется.

GET /api/airlines/routes/airline/:iata/from/:airport и /to/:airport — маршруты из конкретного аэропорта и в него. Удобно для drill-down по географии.

Внутри запросы строятся динамически через WHERE-условия, джойнятся с таблицами world_airport и airlines. Для агрегации в PostgreSQL используется array_agg() — это позволяет в одном запросе собрать, например, все номера рейсов в массив, не делая N отдельных запросов.

airlineController — авиакомпании

Поиск работает сразу по нескольким полям: имя, IATA, ICAO, позывной. Плюс поиск в таблице переводов через EXISTS-подзапрос — чтобы если ввести «Аэрофлот», нашёлся SU.

Через параметр lang=ru к каждой авиакомпании в ответе прикрепляется перевод из airline_translations. Реализовано через хелпер attachTranslations, который батчами джойнит переводы к уже загруженным записям.

Статус active обновляется отдельным эндпоинтом: сравниваем текущие записи с тем, что отдаёт AirLabs, и проставляем флаги.

airportController — аэропорты

По сути справочник. Поиск по имени, IATA, ICAO, городу. Фильтрация по стране (iso_country) и типу аэропорта. Отдельный эндпоинт GET /api/airlines/airports/iata/:code — точечный lookup по IATA-коду. Он используется на фронтенде, когда встречается аэропорт, которого нет в локальном кеше.

Формат ответов везде единообразный:

json Copy
{
  "success": true,
  "data": [...],
  "pagination": { "total": 885, "limit": 200, "offset": 0 }
}

Часть 5. Фронтенд и рендеринг маршрутов

Фронтенд — Vue 3 + TypeScript, сборка на Vite. Никаких картографических библиотек типа Leaflet или Mapbox — всё на голом canvas. Это принципиальное решение: хотелось полного контроля над тем, как рисуются маршруты, без борьбы с чужими абстракциями.

Инициализация

При загрузке страницы запускается PipelineOrchestrator из библиотеки rest-pipeline-js — он выполняет несколько запросов последовательно:

  1. /api/airlines/routes/stats/airlines — список АК для селектора (только те, у кого есть маршруты)
  2. /api/airlines/airports?country=RU&type=large_airport&limit=200 — крупные аэропорты РФ
  3. /api/airlines/airports?country=RU&type=medium_airport&limit=200 — средние аэропорты РФ

Аэропорты сразу раскладываются в локальный airportCache — словарь { [iata]: { iata, name, lat, lon } }. Этот кеш живёт на всё время сессии и пополняется на лету.

Выбор авиакомпании и загрузка маршрутов

Когда пользователь выбирает АК из списка, срабатывает watch(selectedAirline) и запускается загрузка маршрутной сети:

Copy
GET /api/airlines/routes/airline/{iata}/destinations

Ответ содержит массив пар { dep_iata, arr_iata }. Для каждого IATA-кода проверяем кеш: если аэропорт уже есть — берём оттуда координаты. Если нет — догружаем точечно через /api/airlines/airports/iata/{iata}. Таких «незнакомых» аэропортов обычно немного — в основном это экзотические направления за пределами России.

После загрузки маршруты перегруппировываются в формат [{ departure: 'SVO', arrival: ['LED', 'AER', ...] }, ...] и передаются в composable useDrawAirlines, который занимается отрисовкой.

Рендеринг на canvas

Карта состоит из двух слоёв:

Canvas — тут рисуется всё: тайлы карты, дуги маршрутов, кружки аэропортов. Зум и панорамирование тоже обрабатываются здесь через pointer events.

HTML-оверлей — поверх canvas, с pointer-events: none, находится слой с подписями аэропортов в виде обычных <div>. Это даёт несколько преимуществ по сравнению с отрисовкой текста на canvas: нормальное сглаживание шрифтов, возможность кликнуть по метке, стандартные CSS-анимации. Клики на метках обрабатываются с маленькой хитростью — если пользователь не просто кликнул, а начал тащить карту, событие перехватывается и форвардится на canvas, чтобы не прерывать панорамирование.

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

Кеширование визуализации
Наивный подход — перерисовывать всё на каждый кадр — быстро упирается в производительность. У Ryanair 5000+ маршрутов, каждый — кривая Безье с вычислением контрольных точек, разнесением по lane'ам и двухпроходной отрисовкой (сначала обычные, потом подсвеченные поверх). Перерисовывать это при каждом движении мыши — гарантированные фризы.

Решение — трёхуровневое offscreen-кеширование. Для маршрутов, точек аэропортов и подписей городов создаются отдельные offscreen-канвасы, которые перерисовываются только когда это действительно нужно, а в остальное время просто композитятся на основной canvas со смещением.

Маршруты (linesCanvas) — самый тяжёлый слой. Все дуги рисуются на отдельный offscreen-канвас, размер которого — viewport плюс большой margin с каждой стороны (равен max(width, height)). При панорамировании кешированная картинка просто сдвигается — никакой перерисовки. Перерендер запускается только когда пользователь утаскивает карту достаточно далеко (>75% margin'а), меняет выделенный город или выбирает другую авиакомпанию.

Точки аэропортов (citiesCanvas) — аналогичная схема: offscreen-канвас с margin'ом, композитинг при пане, перерисовка при дрифте или смене выделения.

Подписи городов (cachedLabelPlacements) — здесь кеш другого рода. Подписи рисуются не на canvas, а рендерятся как HTML-элементы через Vue. Но для каждой подписи нужно определить placement — с какой стороны от точки её разместить, чтобы она не перекрывала ни другие подписи, ни соседние точки. Это O(n²) проход с проверкой пересечений прямоугольников. Вычисление запускается при смене АК, смене выделенного города или при зуме, а при панорамировании направления placement'ов берутся из кеша и только пересчитываются координаты — простое сложение смещений, без overlap detection.

Зум обрабатывается отдельно. Пока пользователь крутит колесо, перерисовывать offscreen-канвасы на каждое событие слишком дорого. Вместо этого кешированная картинка масштабируется через drawImage с пересчитанными размерами — получается слегка пикселизованный превью, зато 60 fps. Полноценная перерисовка запускается с debounce 250ms после последнего события зума.

Дополнительно все draw-вызовы при быстром перемещении throttled через requestAnimationFrame — если за один кадр приходит несколько pointermove, рисуется только один раз.


Итого

В проекте сошлись несколько отдельных задач, которые я хотел порешать:

  • Работа с геоданными и генерация тайлов — раньше не делал
  • Сбор и нормализация данных из разных источников (API с лимитами, открытые репозитории, SPARQL)
  • Проектирование REST API с кешированием на уровне сервиса
  • Рендеринг на canvas без картографических библиотек

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

Всё крутится на собственном сервере в Docker. Данные периодически обновляются скриптами. Посмотреть можно на airlines.macrulez.ru.

Читать далее

14.03.2026

3D-режим отображения маршрутных сетей авиакомпаний на основе Three.js

Проект airlines.macrulez.ru изначально строился вокруг 2D-карты: тайлы, canvas, кривые Безье. Всё это описано в предыдущей статье. Но плоская карта с проекцией Меркатора неизбежно искажает маршруты — особенно дальние перелёты, которые в реальности идут через Арктику. Логичным следующим шагом стало добавление интерактивного 3D-глобуса.

Метки
three.jswebglvue3gisdata-visualization
22.03.2026

2D-режим на Three.js и всплывающий список авиакомпаний

Продолжаю развивать интерактивную карту маршрутных сетей авиакомпаний. В этот раз — две заметные вещи: переписал 2D-режим на Three.js и добавил удобный способ выбирать авиакомпанию прямо из карточки аэропорта.

Метки
three.jswebglавиациявизуализацияfrontend
22.03.2026

Автоматизация деплоя: от пуша до прогретого кеша

Ручной деплой — это всегда лотерея. Забытый npm run build перед копированием файлов, кеш старой версии на сервере, тесты которые «ну и так понятно что работает». В какой-то момент надоело играть в эту игру и я собрал нормальный пайплайн на GitHub Actions.

Метки
CI/CDGitHub ActionsDevOpsавтоматизациядеплой